docs(notes): measured .NET optimization limits and GC/LOH observability - #327
Conversation
…s GC/LOH observability Two research notes from a design discussion, both recorded and neither scheduled, per the research-landscape-2026 discipline. llvm-codegen-feasibility.md answers "can .NET code get C/C++-grade compiler optimizations, and is a PoC doable?" with a committed, reproducible harness rather than an opinion. Three results decide it: - A naive CIL lowering (per-element bounds check with a noreturn throw, array length reloaded from the object header) switches LLVM's auto-vectorizer off entirely. Hoist the check and both blocked kernels vectorize. The enabling work is .NET-specific range-check elimination in the frontend, not anything LLVM does for free. - noalias bought 0% on every kernel measured, so the ownership-to-noalias-to- speed story that makes Rust fast does not reproduce here. This is the finding that says the idea is a codegen project, not an Own.NET project. - Hand-written C# Vector256 matched LLVM -O3 on one kernel and beat clang -O3 by 1.8x on another, so LLVM is automation rather than a capability ceiling. Also records what RyuJIT actually does (disassembly shows loop cloning, hoisted length/null checks, a bounds-check-free hot loop, strength reduction) and what it does not (vectorize, unroll) - and why no compiler hoists an opaque LINQ call out of a loop, which makes that a static-analysis target rather than a codegen one. gc-observability-and-loh.md takes the one correction worth having from the GCExperiment write-up: the LOH threshold is compared against full object size, so byte[84_999] lands on the LOH at 85,024 bytes and the "keep buffers under 85,000" folklore is off by a header. The detectability-matrix boundary is restated explicitly - cheaper GC observation is not an argument for static inference, and LOH fragmentation stays runtime-only. The harness enforces a 5000-call warmup and a cross-variant correctness gate because both traps were hit while producing these numbers: a short warmup inflated LLVM's advantage by ~40%, and an unequal accumulator width produced a flattering 33x that was simply less work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PbyzyVi7fibuSKLweuXgea
…yer load-bearing Follow-up to llvm-codegen-feasibility.md, answering the objection that a static rule can reach the shape of a loop-invariant call but never its cost. Measured: four call shapes, each evaluated in-loop vs hoisted by hand, over collection sizes 4..100_000. Every row is the same syntactic shape, so a static rule matching that shape reports them identically. The penalty ranges from 1.0x (List.Count, an O(1) property -- a textbook false positive) to 1092x (OrderBy().First()). The decisive row is Any(x => x > threshold) at n=100_000: 8.3x when the predicate hits at element 0, 1025x when it never hits. The source is character-for-character identical; only the runtime value of threshold and the data distribution differ. No static analysis can separate those, and collection size does not predict cost either -- the short-circuiting row is flat across n while the other grows a hundredfold, so "flag only for large collections" is wrong in both directions. The conclusion is not that the static rule is worthless but that it and a profiler are each unactionable alone and complete each other: the profiler supplies magnitude without knowing the call is safely hoistable, the rule supplies the proof and the fix without knowing whether it buys 0% or 99.9%. That is Plan.md's existing Layer 1 -> Layer 2 shape, reusing the same confirmation pattern already used for subscription leaks, with a timing witness in place of a heap walk. It also repairs this note's parent, which flagged "expensive" as a false-positive generator: that holds for a static predicate, but measured it stops being a predicate and becomes a number. Also expands the Burst row of the landscape table into a full subsection, since the mechanism matters: Burst does not defeat the blockers found earlier, it defines them out of the language (no object header means no per-iteration ldlen; job-system bounds are loop-invariant by construction). Its value to us is cost calibration first, a real but partial design precedent second -- the parallel to OwnLang's buffer policies holds only for the restricted-language part, since the ownership content was measured at 0% -- and nothing as a component. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PbyzyVi7fibuSKLweuXgea
The invariant-cost note filed a LINQ loop-invariance rule under "P-036 territory". P-036 is the right host for the machinery (call graph, MethodSummary, SCC composition), but none of its five summary domains -- ownership, obligation, progress, region, task -- is a purity/effect-freedom domain, and effects are owned by P-008, which is explicitly horizon. A reader would otherwise open P-036 expecting to find the domain there. Also records that P-036's unknown/external-call policy would classify Any(userLambda) as unresolved or unsupported, so under its own rules the static half yields a candidate with declared uncertainty rather than a verdict -- independently reaching the note's conclusion that magnitude comes from the runtime layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PbyzyVi7fibuSKLweuXgea
|
Warning Review limit reached
Next review available in: 43 minutes You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (7)
📝 WalkthroughWalkthroughAdded three research areas: GC and LOH observability, loop-invariant runtime costs, and LLVM code-generation feasibility. The changes include technical notes, .NET 9 benchmark programs, native C kernels, project files, and reproducible benchmark scripts. ChangesGC observability and LOH
Loop-invariant cost analysis
LLVM code-generation feasibility
Estimated code review effort: 4 (Complex) | ~45 minutes 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c6578d2fb7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Actionable comments posted: 13
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/notes/gc-observability-and-loh.md`:
- Around line 15-28: Update the LOH sizing guidance to account for effective
runtime configuration rather than treating 85,000 as universal: mention that
System.GC.LOHThreshold or DOTNET_GCLOHThreshold may override the default, and
avoid asserting a fixed 24-byte array header because object layout and alignment
vary by platform. Identify the measured environment, such as .NET 9 CoreCLR x64,
or make the recommendation simply to measure.
- Around line 56-63: Revise the GC probe description to identify
GC.CollectionCount(n) and GC.GetGCMemoryInfo() as process-wide heuristics
affected by prior, parallel, or background collections, not isolated scenario
snapshots. Before using the probe as an assertion, record the collection count
at the scenario boundary and compare GetGCMemoryInfo() only when the
corresponding GC index matches; retain the existing debug-only caveat.
In `@docs/notes/invariant-cost-data/Program.cs`:
- Around line 51-56: Update the comment above C_Inline to describe
OrderBy(...).First() as repeated linear scanning and key comparison via LINQ’s
specialized path, not allocation and full sorting on every iteration. Keep the
benchmark code unchanged unless the intended measurement is full sorting, in
which case enumerate the ordered sequence before taking the first element.
- Around line 94-99: Update Row to evaluate inline() and hoisted() before
calling Bench, and throw when their results differ instead of only appending a
mismatch marker; keep timing and output for equivalent implementations unchanged
so run.sh receives a non-zero exit status on failure.
- Around line 34-39: Rename the shape B benchmark label and its corresponding
Markdown row to explicitly describe Count() on a Select-wrapped sequence rather
than all IEnumerable<T> values. Update the visible labels around B_Inline and
the matching section near the later referenced lines, while leaving the
benchmark implementation unchanged.
In `@docs/notes/invariant-cost-data/run.sh`:
- Line 3: Update the run script’s .NET SDK requirement so it either enforces SDK
version 9.0.316 and reports the selected runtime with dotnet --info, or revise
the documentation to explicitly support any .NET 9 patch version. Ensure the
measurement command’s actual SDK/runtime selection is visible and consistent
with the reproducibility claim.
- Around line 5-7: Update the run script’s WORK initialization to track whether
the directory was created by the script, then register an EXIT trap that removes
only that owned temporary directory. Preserve caller-provided WORK directories
without deleting them.
- Around line 10-11: Update the benchmark mode labeling in
docs/notes/invariant-cost-data/run.sh lines 10-11 and
docs/notes/invariant-cost-static-vs-runtime.md lines 19-22 to consistently
describe DOTNET_TieredCompilation=0 as non-tiered mode, removing references to
tier-1 measurement unless the benchmark is intentionally changed to measure
tier-1 promotion.
In `@docs/notes/invariant-cost-static-vs-runtime.md`:
- Around line 42-45: Revise the conclusion in “The dynamic range is three orders
of magnitude” to limit claims to the measured sample: replace “costing exactly
nothing” with “no measurable penalty in this run,” and replace “useless for
prioritisation” with “cannot prioritize this sample without runtime data.”
- Around line 116-118: Update the statement in the .NET 4.7.2 measurement
discussion to remove the unsupported “lower bound” inference and describe the
.NET 9 results as not comparable to .NET Framework. Retain the fact that .NET
4.7.2 was not measured, and only make a lower-bound claim if matched Framework
measurements are added.
- Around line 54-56: Revise the claim in the discussion around Main and A_Inline
to scope it to analyzers that lack runtime or call-site input values.
Acknowledge that whole-program analysis can distinguish the benchmark’s
deterministic Enumerable.Range and constant arguments, while preserving the
conclusion that a local rule cannot predict costs for unknown general inputs.
In `@docs/notes/llvm-codegen-feasibility-data/Kernels.cs`:
- Around line 135-142: Update the K3 correctness gate around FilterSumManaged,
FilterSumBranchless, FilterSumSimd, FilterSumNative, and FilterSumFree to define
a named expected-result constant of 25830282 and validate r0 against it before
the existing pairwise implementation comparisons; retain the current mismatch
reporting for disagreements between variants.
In `@docs/notes/llvm-codegen-feasibility.md`:
- Around line 44-48: Update the K3 correctness-gate text to state that the
harness checks five variants, adding both native implementations—per-element
check and hoisted check—to the existing scalar, branchless, and hand-SIMD list.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: b4d00fff-f239-4681-9af7-53da646990ee
📒 Files selected for processing (16)
docs/notes/gc-observability-and-loh.mddocs/notes/invariant-cost-data/Program.csdocs/notes/invariant-cost-data/linq.csprojdocs/notes/invariant-cost-data/run.shdocs/notes/invariant-cost-static-vs-runtime.mddocs/notes/llvm-codegen-feasibility-data/Kernels.csdocs/notes/llvm-codegen-feasibility-data/Program.csdocs/notes/llvm-codegen-feasibility-data/README.mddocs/notes/llvm-codegen-feasibility-data/bench.csprojdocs/notes/llvm-codegen-feasibility-data/checkfree.cdocs/notes/llvm-codegen-feasibility-data/lenprobe.cdocs/notes/llvm-codegen-feasibility-data/matrix.cdocs/notes/llvm-codegen-feasibility-data/native.cdocs/notes/llvm-codegen-feasibility-data/native2.cdocs/notes/llvm-codegen-feasibility-data/run.shdocs/notes/llvm-codegen-feasibility.md
…e the gate fail Verified each finding before acting; all are accuracy fixes to my own notes and harnesses, and none changes a measured result. Harness correctness. The invariant-cost Row() only PRINTED a mismatch, so a non-equivalent pair would still exit 0 and be reported as a measurement -- it now checks before timing and throws, which also makes the note's "asserted" wording true. The K3 gate compared the five variants only to each other, where a shared defect passes; it now also pins the expected constant 25830282. run.sh no longer leaks the mktemp workdir it creates (trap on EXIT, only when it owns the directory) and prints the SDK and runtime it actually selected instead of merely claiming 9.0.316. Overclaims scoped to the evidence. "No static analysis can separate those two, ever" was too strong: Main builds data with a deterministic Enumerable.Range and passes constant thresholds, so a whole-program analyzer could constant-propagate these particular sites. The claim is now about a local rule and about general unknown inputs, which is the case a real rule faces. "Costing exactly nothing" and "useless for prioritisation" become no measurable penalty in this run and cannot rank this sample without runtime data. The .NET 9 numbers are no longer called a lower bound for 4.7.2 -- different JIT and different LINQ, so they are simply not comparable without a matched Framework measurement. LOH guidance corrected in two ways: 85,000 is the default, movable by System.GC.LOHThreshold and DOTNET_GCLOHThreshold, and the 24-byte overhead is a CoreCLR-x64 detail rather than a portable constant, so the portable advice is to measure. The GC probe now carries the caveat that CollectionCount and GetGCMemoryInfo are process-wide, moved by background and parallel GC, with GetGCMemoryInfo zero-valued at Index 0 when no collection of that kind occurred. Labels made accurate: five K3 variants rather than four, Count() on a Select-wrapped sequence rather than all IEnumerable, OrderBy().First() as repeated linear key scanning via .NET 9's TryGetFirst rather than a full sort per iteration, and DOTNET_TieredCompilation=0 described as non-tiered FullOpts rather than tier 1. Both harnesses re-run clean: the vectorization matrix is unchanged and every reported number reproduces. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PbyzyVi7fibuSKLweuXgea
|
@coderabbitai review |
Что и зачем
Три исследовательские заметки с воспроизводимыми замерами, отвечающие на два вопроса: можно ли получить для .NET-кода оптимизации уровня C/C++ через LLVM, и что RyuJIT на самом деле оптимизирует. Ответы измерены, а не выведены из общих соображений; харнессы закоммичены и воспроизводят каждое число. Ничего не планируется в работу — это
recorded, not scheduled.Три результата решают вопрос про LLVM, и главный из них отрицательный для нас:
noreturn-броском на каждыйldelem, длина массива, перечитываемая из заголовка) полностью выключает автовекторизатор LLVM. Вынеси проверку — оба заблокированных кернела векторизуются. Включающая работа — это .NET-специфичный range-check elimination во фронтенде, а не то, что LLVM делает даром.noaliasдал 0% на всех замеренных кернелах: история «ownership → noalias → скорость», которая делает Rust быстрым, здесь не воспроизвелась. Это и есть вывод, что идея — чужой compiler-проект, а не проект Own.NET.Vector256сравнялся с LLVM-O3на одном кернеле и обошёл clang-O3в 1.8× на другом. LLVM здесь — автоматизация, а не недостижимый потолок.Отдельная заметка измеряет разброс стоимости loop-invariant вызова: от 1.0× до 1092× при синтаксически одинаковой форме кода. Решающая пара —
Any(x => x > threshold)при n=100 000: 8.3× против 1025× на посимвольно одинаковом исходнике, разница только в рантайм-значении и распределении данных. Отсюда вывод, что статическое правило и профайлер по отдельности неactionable и дополняют друг друга — то есть уже существующая вPlan.mdсхема Слой 1 → Слой 2.Третья заметка берёт из разбора GCExperiment одну поправку с аргументом корректности: порог LOH сравнивается с полным размером объекта, поэтому
byte[84_999]уезжает на LOH при 85 024 байтах, и фольклор «держи буферы под 85 000» промахивается на заголовок. Границу матрицы детектируемости при этом не двигает: LOH-фрагментация остаётся runtime-only.Тип изменения
Как проверено
Кода проекта изменение не трогает — только
docs/notes/, поэтому тесты репозитория к нему не применимы и не гонялись.Проверено то, что заметки утверждают:
docs/notes/llvm-codegen-feasibility-data/run.sh— прогнан от чистого состояния, воспроизводит матрицу векторизации (-Rpass=loop-vectorize), бенчмарк RyuJIT против LLVM и дамп дизассемблера RyuJIT.docs/notes/invariant-cost-data/run.sh— прогнан, воспроизводит таблицу 1.0×–1092×.25830282(неравная ширина аккумулятора давала лестные 33×, которые были просто меньшей работой).Окружение замеров: Intel Xeon @ 2.10GHz (4 vCPU, AVX-512), clang/LLVM 18.1.3, .NET SDK 9.0.316,
linux-x64.Связанные issue
Нет. Ни одна заметка не заводит work item — по дисциплине
research-landscape-2026.mdзаметки фиксируют, планирует ROADMAP.Чеклист
run.sh, прогнанные от чистого состоянияROADMAP.md/Plan.mdнамеренно не трогались, обе заметки объясняют почемуfeat:,fix:,docs:…)Ограничения замеров записаны в самих заметках честно: одна машина с шумными соседями, четыре кернела — не корпус, C-сторона это прокси CIL-лоуринга, а не настоящий CIL, взаимодействие с GC не моделируется вообще, и .NET 4.7.2 (где был исходный случай) не замерялся — числа на .NET 9 являются нижней границей штрафа.
Отдельно: коммиты намеренно не сквошены — последовательность показывает, как первоначальная гипотеза про
noaliasбыла опровергнута измерением.Generated by Claude Code
Summary by CodeRabbit